package com.google.code.xmlformattermavenplugin;

/*
 * Copyright 2001-2005 The Apache Software Foundation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.File;
import java.io.FileInputStream;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.CharUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.SystemUtils;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.codehaus.plexus.util.DirectoryScanner;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.tidy.Tidy;
import org.w3c.tidy.TidyMessage;
import org.w3c.tidy.TidyMessage.Level;
import org.w3c.tidy.TidyMessageListener;

/**
 * Goal that formats xml files.
 * @goal format
 * @phase generate-resources
 */
public class FormatMojo extends AbstractMojo {

    private static final String SCHEMALOCATION_ATTRIBUTE = "schemaLocation";
    private static final String SCHEMALOCATION_ATTRIBUTE_SUFFIX =
            ":" + SCHEMALOCATION_ATTRIBUTE;
    /**
     * Print verbose output.
     * @parameter default-value="true"
     */
    boolean verbose = true;
    /**
     * Base directory where to search for files.
     * @parameter default-value="${basedir}"
     * @required
     */
    File root;
    /**
     * Patterns of files to include.
     * Default is pom.xml, src/** /*.xml
     * @parameter
     */
    String[] includes;
    /**
     * Patterns of files to exclude.
     * Default is target/**
     * @parameter
     */
    String[] excludes;
    /**
     * Indent xml files.
     * @parameter default-value="true"
     * @required
     */
    boolean formatIndenting = true;
    /**
     * Indentation size.
     * @parameter default-value="4"
     * @required
     */
    int formatIndent = 4;
    /**
     * Line width for xml files.
     * @parameter default-value="80"
     * @required
     */
    int formatLineWidth = 80;
    /**
     * Re-format value of schemaLocation attribute.
     * @parameter default-value="true"
     * @required
     */
    boolean formatReformatSchemaLocation = true;
    /**
     * Add xml declaration if needed.
     * @parameter default-value="true"
     * @required
     */
    boolean formatAddXmlDeclaration = true;
    /**
     * End of line style to use.
     * Could be one of: NATIVE, LF, CRLF.
     * Native line ending are used by default.
     * @parameter
     */
    String formatEOL = LineEndings.NATIVE.name();

    private enum LineEndings {

        LF(new char[]{'\n'}),
        CRLF(new char[]{'\r', '\n'}),
        NATIVE(SystemUtils.LINE_SEPARATOR.toCharArray());
        private final char[] bytes;

        private LineEndings(char[] bytes) {
            this.bytes = bytes;
        }

        public char[] getBytes() {
            return bytes;
        }
    }
    final String[] defaultIncludes = new String[]{
        "pom.xml", "src/**/*.xml"
    };
    final String[] defaultExcludes = new String[]{
        "target/**"
    };

    Collection<File> listFilesToFormat() {
        DirectoryScanner scanner = new DirectoryScanner();
        scanner.setBasedir(root);
        scanner.setIncludes(includes);
        scanner.setExcludes(excludes);
        scanner.addDefaultExcludes();
        scanner.scan();
        String[] paths = scanner.getIncludedFiles();
        Collection<File> files = new ArrayList<File>(paths.length);
        for (String relativePath : paths) {
            files.add(new File(root, relativePath));
        }
        return files;
    }
    Tidy tidy;

    void setupTidy() {
        tidy = new Tidy();
        tidy.setXmlTags(true);
        tidy.setXmlOut(true);
        tidy.setRawOut(true);
        tidy.setEscapeCdata(false);
        tidy.setLowerLiterals(false);
        tidy.setIndentContent(formatIndenting);
        tidy.setSpaces(formatIndent);
        tidy.setWraplen(formatLineWidth);
        tidy.setXmlPi(formatAddXmlDeclaration);
        tidy.setMessageListener(new ReportingListener());
        tidy.setErrout(new PrintWriter(new NullOutputStream()));
        tidy.setNewline(LineEndings.valueOf(formatEOL).getBytes());
        tidy.setNoEntitiesExpansion(true);
    }

    @Override
    public void execute() throws MojoExecutionException {
        if (excludes == null) {
            excludes = defaultExcludes;
        }
        if (includes == null) {
            includes = defaultIncludes;
        }
        Collection<File> files = listFilesToFormat();
        for (File file : files) {
            try {
                getLog().debug("Loading file " + file.getPath());
                Document document = parseXmlFile(file);
                if (formatReformatSchemaLocation) {
                    getLog().debug("Re-formatting schemaLocation...");
                    reformatSchemaLocation(document);
                }
                getLog().debug("Saving...");
                serializeDocumentInto(document, file);
            } catch (FormattingException formattingException) {
                throw new MojoExecutionException("Error formatting "
                        + file.getPath(), formattingException);
            }
        }
    }

    boolean isSameContent(File file, String content) throws IOException {
        if (!file.isFile()) {
            return false;
        }
        boolean sameLength =
                StringUtils.length(content) == FileUtils.sizeOf(file);
        boolean sameContent =
                StringUtils.equals(content, FileUtils.readFileToString(file));
        return sameLength && sameContent;
    }

    void serializeDocumentInto(Document document, File file)
            throws FormattingException {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        tidy.pprint(document, outputStream);
        String newContent = outputStream.toString();
        boolean alreadyFormatted;
        try {
            alreadyFormatted = isSameContent(file, newContent);
        } catch (IOException iOException) {
            throw new FormattingException(
                    "Cannot determine if file is already formatted " + file,
                    iOException);
        }
        if (!alreadyFormatted) {
            if (verbose) {
                getLog().info("Reformatted " + file.getPath());
            }
            try {
                FileUtils.writeStringToFile(file, newContent);
            } catch (IOException iOException) {
                throw new FormattingException(
                        "Error writing output file " + file, iOException);
            }
        } else if (verbose) {
            getLog().info("Already formatted " + file.getPath());
        }
    }

    Document parseXmlFile(File file) throws FormattingException {
        if (tidy == null) {
            setupTidy();
        }
        try {
            FileInputStream inputStream = new FileInputStream(file);
            try {
                return tidy.parseDOM(inputStream, null);
            } finally {
                inputStream.close();
            }
        } catch (IOException e) {
            throw new FormattingException("Error reading input file "
                    + file.getPath(), e);
        }
    }

    void reformatSchemaLocation(Document document) {
        Collection<Attr> schemaLocations = findSchemaLocations(document);
        for (Attr schemaLocationNode : schemaLocations) {
            String oldValue = schemaLocationNode.getValue();
            getLog().debug("Found " + schemaLocationNode + " in "
                    + schemaLocationNode.getParentNode()
                    + " with value " + oldValue);
            String newValue = reformatSchemaLocationValue(oldValue);
            schemaLocationNode.setValue(newValue);
            getLog().debug("New value set to " + newValue);
        }
    }

    String reformatSchemaLocationValue(String oldValue) {
        String[] parts = StringUtils.split(
                StringUtils.normalizeSpace(oldValue),
                ' ');
        if (parts == null) {
            return null;
        }
        if (ArrayUtils.getLength(parts) == 1) {
            return parts[0];
        }
        return StringUtils.join(
                ArrayUtils.addAll(new Object[]{null}, parts),
                CharUtils.LF);
    }

    Collection<Element> collectElements(Document document) {
        Collection<Element> elements = new LinkedList<Element>();
        Queue<Node> nodesToProcess = new LinkedList<Node>();
        nodesToProcess.add(document);
        while (!nodesToProcess.isEmpty()) {
            Node currentNode = nodesToProcess.remove();
            if (currentNode.getNodeType() == Node.ELEMENT_NODE) {
                elements.add((Element) currentNode);
            }
            NodeList childs = currentNode.getChildNodes();
            for (int i = 0; i < childs.getLength(); i++) {
                nodesToProcess.add(childs.item(i));
            }
        }
        return elements;
    }

    Collection<Attr> findSchemaLocations(Document document) {
        Collection<Attr> schemaLocations = new LinkedList<Attr>();
        Collection<Element> elements = collectElements(document);
        for (Element element : elements) {
            NamedNodeMap attributes = element.getAttributes();
            for (int i = 0; i < attributes.getLength(); i++) {
                Attr attribute = (Attr) attributes.item(i);
                String name = attribute.getName();
                if (name.equals(SCHEMALOCATION_ATTRIBUTE)
                        || name.endsWith(SCHEMALOCATION_ATTRIBUTE_SUFFIX)) {
                    schemaLocations.add(attribute);
                }
            }
        }
        return schemaLocations;
    }

    private interface Reporter {

        void reportEvent(TidyMessage message);
    }

    private class ReportingListener implements TidyMessageListener {

        private Map<Level, Reporter> reporters;

        public ReportingListener() {
            reporters = new HashMap<Level, Reporter>();
            reporters.put(Level.ERROR, new Reporter() {

                public void reportEvent(TidyMessage message) {
                    getLog().error(message.getMessage());
                }
            });
            reporters.put(Level.WARNING, new Reporter() {

                public void reportEvent(TidyMessage message) {
                    getLog().warn(message.getMessage());
                }
            });
        }

        public void messageReceived(TidyMessage message) {
            Reporter reporter = reporters.get(message.getLevel());
            if (reporter != null) {
                reporter.reportEvent(message);
            }
        }
    }
}
